fix(coordinator): prioritize event label intent and sync push provider lock state - #701
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #701 +/- ##
==========================================
+ Coverage 84.14% 93.99% +9.85%
==========================================
Files 10 42 +32
Lines 801 5433 +4632
Branches 0 30 +30
==========================================
+ Hits 674 5107 +4433
- Misses 127 326 +199
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This comment was marked as resolved.
This comment was marked as resolved.
tykeal
left a comment
There was a problem hiding this comment.
Walkthrough
Adds a kmlock.lock_state synchronization to _handle_lock_state_change for push-capable providers, so the in-memory lock state no longer desyncs when a provider never emits a lock event (issue #699, bug 4). Also resets _last_unlock_code_slot to 0 on the unlock path, and updates/adds tests in tests/test_coordinator.py.
Changes
custom_components/keymaster/coordinator.py—_handle_lock_state_change: whenuses_provider_lock_eventsis true, assignkmlock.lock_state = UNLOCKED/LOCKEDafter the autolock branches, and zero_last_unlock_code_sloton unlock.tests/test_coordinator.py— flip two existing assertions to the new synced state; addtest_handle_provider_lock_event_prioritizes_event_labelandtest_handle_lock_state_change_syncs_push_provider_lock_state.
Review Comments
Blocking. The lock_state sync is a real fix for the desync, but as written it makes the push-provider event path order-dependent and silently disables its side effects when the entity state change is processed first. Details inline.
Cross-cutting
[BLOCKER] The stated _handle_provider_lock_event change is not in the diff.
The PR title and summary claim the event-label intent is now prioritized over entity-state mismatch, but _handle_provider_lock_event is untouched: state_changed is still evaluated first and still wins over an unambiguous "unlock" label (coordinator.py L861-871). Bug 3 of #699 is only indirectly mitigated — once lock_state stays in sync, state_changed is less likely to be spuriously true. That is a behavioural coincidence, not a precedence change, and it regresses the moment lock_state drifts again (e.g. a provider event is throttled, or the lock reports unavailable in between). Either implement the precedence change or correct the title/description so the changelog does not claim a fix that is not present.
[BLOCKER] Every provider is a push provider — the blast radius is 100% of installs.
supports_push_updates returns True unconditionally in zwave_js.py, zha.py, zigbee2mqtt.py and akuvox.py. There is no polled provider in-tree, so uses_provider_lock_events is true for every configured lock and both new branches execute for every user on every lock/unlock. This is not a Z2M-scoped change; it needs to be evaluated as a change to the Z-Wave JS and ZHA hot paths too.
[BLOCKER] Interaction with #695 (refactor/682-dirty-lock-refresh-pipeline).
(a) Missing scoped notification. Both new kmlock.lock_state mutations sit in an if uses_provider_lock_events: block that is not nested under the autolock elif, so they execute on paths where no notification is scheduled at all. lock_state is consumer-visible (e.g. switch.py L300 gates autolock-timer start on kmlock.lock_state == LockState.UNLOCKED). On main the global fan-out from unrelated events masks this. Under #695 notifications are scoped to dirty locks, so a mutation with no async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) is silent staleness. Once #695 lands, both new blocks need a scoped notification for kmlock.keymaster_config_entry_id.
(b) Merge conflict surface. Both of #701's hunks are anchored directly on self.async_schedule_global_notification() lines that #695 rewrites to self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) (#695's coordinator.py L908 and L923). Both hunks will conflict textually, and tests/test_coordinator.py conflicts as well (#701 edits the async_schedule_global_notification.assert_called_once() assertions in test_handle_lock_state_change_unlocked / _locked, which #695 also rewrites). Suggested landing order: merge #695 first (it is green and reviewed clean, and it is a pure refactor with no behavioural dependency on #701), then rebase #701 onto it and add the scoped notification calls. Rebasing #695 onto #701 would silently drop the notification requirement in (a).
|
Thanks for highlighting the 3 blockers in the review summary:
All 167 coordinator tests pass cleanly. |
|
main has moved; this now conflicts only in <<<<<<< upstream/main
self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id])
=======
self.async_schedule_global_notification()
if uses_provider_lock_events:
kmlock.lock_state = LockState.UNLOCKED
self._last_unlock_code_slot[kmlock.keymaster_config_entry_id] = 0
>>>>>>> upstream/pr/701
<<<<<<< upstream/main
self.async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id])
=======
self.async_schedule_global_notification()
if uses_provider_lock_events:
kmlock.lock_state = LockState.LOCKED
>>>>>>> upstream/pr/701 |
4c6cbba to
09f770d
Compare
tykeal
left a comment
There was a problem hiding this comment.
Rebase onto post-#695 main is correct. Both conflicted hunks in _handle_lock_state_change retain the scoped async_schedule_keymaster_notifications([kmlock.keymaster_config_entry_id]) call and add the new uses_provider_lock_events lines. Neither side was dropped.
Verification against head 09f770d5cf072a631cb67a224ca768fcc9ba2a2a (merge base 83a572e):
| Check | Result |
|---|---|
pytest tests/ |
1060 passed, 1 deselected, 16 warnings |
ruff check custom_components/ tests/ |
All checks passed (no TRY401) |
ruff format --check custom_components/ tests/ |
80 files already formatted |
mypy custom_components/keymaster/ |
Success, 35 source files |
| Patch coverage (coordinator.py L1021-1023, L1039-1040) | 100% — none appear in --cov-report=term-missing |
| Commit messages | Conventional-commit with scope; consistent with git log upstream/main |
Mutation testing of every added assertion (production change reverted in a throwaway worktree, suite re-run):
| Reverted change | Failing test(s) | Observed message |
|---|---|---|
| Both unlock lines (L1021-1023) | test_handle_lock_state_change_unlocked, test_handle_lock_state_change_syncs_push_provider_lock_state |
AssertionError: assert <LockState.LOCKED: 'locked'> == <LockState.UNLOCKED: 'unlocked'> |
Only _last_unlock_code_slot[...] = 0 (L1023) |
test_handle_lock_state_change_syncs_push_provider_lock_state, test_state_change_before_slot_event_does_not_swallow_slot_notification |
slot tracker None != 0; slot notification not sent |
| Both lock lines (L1039-1040) | test_handle_lock_state_change_locked, test_state_change_relock_clears_autolock_marker_before_next_unlock |
AssertionError: assert <LockState.UNLOCKED: 'unlocked'> == <LockState.LOCKED: 'locked'> |
All added assertions are genuine regression tests; none passed with the fix reverted.
Prior-round findings: #1 is partially resolved (see inline on L1021-1023), #2 is not resolved (inline on L1039-1040), #3 is not resolved (below), #4 is resolved.
[BLOCKER] custom_components/keymaster/coordinator.py L904-969 — _handle_provider_lock_event is unchanged; prior finding 3 stands
Not anchorable inline: these lines are not part of this PR's diff, which is exactly the point.
The net diff of _handle_provider_lock_event against merge base 83a572e is zero. ab2bc044 ("prioritize explicit event_label intent over state_changed") reordered the state_changed / label_lower branches; 09f770d5 ("prioritize state_changed over event_label") restores them. Verified byte-for-byte:
$ diff <(git show 83a572e:custom_components/keymaster/coordinator.py | sed -n '904,970p') \
<(git show 09f770d5:custom_components/keymaster/coordinator.py | sed -n '904,970p')
IDENTICAL
$ git diff 83a572e..09f770d5 -- custom_components/keymaster/coordinator.py | grep -c '^@@'
2
The only two hunks are L1018 and L1036. Final semantics are therefore the pre-existing ones: state_changed wins, event_label is the fallback.
state_changed = (
new_state in (LockState.LOCKED, LockState.UNLOCKED) and new_state != kmlock.lock_state
)
if state_changed:
inferred_action = new_state
elif "unlock" in label_lower:Consequently tests/test_coordinator.py::test_handle_provider_lock_event_prioritizes_event_label exercises code this PR does not change, and passes identically against upstream/main — the same objection as the previous round. Two ways to close this:
- Drop the test from this PR, or move it to a PR that actually changes
_handle_provider_lock_event. - If the reorder-then-restore was intentional, squash
ab2bc044and09f770d5so history does not carry a change and its revert, and keep the test only if a corresponding production change lands with it.
Out of scope (pre-existing on upstream/main)
The _lock_locked / _lock_unlocked early-return guards and the state_changed-over-event_label precedence in _handle_provider_lock_event all predate this PR and are not blocking here. The underlying gap is that there is no idempotency token recording which subsystem already handled a given physical transition, which is what makes the ordering fragile in the first place. Worth a follow-up issue under #699 rather than expanding this PR.
| if uses_provider_lock_events: | ||
| kmlock.lock_state = LockState.LOCKED |
There was a problem hiding this comment.
[BLOCKER] Lock path still swallows the provider lock event's side effects.
if uses_provider_lock_events:
kmlock.lock_state = LockState.LOCKED_lock_locked early-returns at L1460:
if kmlock.lock_state == LockState.LOCKED and not kmlock.pending_retry_lock:
...
self._cancel_pending_keypad_unlock_notification(kmlock)
returnWhen the entity state change lands before the provider lock event, this line marks the lock LOCKED first and the provider event then hits that early return. That ordering is likely, not hypothetical: Z-Wave JS dispatches the callback via self.hass.async_create_task(callback(...)) (providers/zwave_js.py:982), which defers it by a loop iteration, while _handle_lock_state_change runs as a direct state listener; for Z2M the action and the entity state derive from the same MQTT payload.
Probe on this head (state change, then _lock_locked(source="event", event_label="Keypad Lock", action_code=6)):
LOCK PROBE: notify calls = []
LOCK PROBE: dismiss calls = 0
LOCK PROBE: bus events = []
Identical probe against merge base 83a572e:
LOCK PROBE: notify calls = [call(hass=<HomeAssistant RUNNING>, script_name='notify_front_door', title='Front Door', message='Keypad Lock')]
LOCK PROBE: dismiss calls = 3
LOCK PROBE: bus events = [{'notification_source': 'event', 'lockname': 'Front Door', 'entity_id': 'lock.front_door', 'state': <LockState.LOCKED: 'locked'>, 'action_code': 6, 'action_text': 'Keypad Lock'}]
So this PR loses the keymaster_lock_state_changed bus event with ATTR_STATE: LOCKED, the lock notification, and all three dismiss_persistent_notification() calls over AUTOLOCK_NOTIFICATION_SUFFIXES. This is precisely finding 2 from the previous round, still reproducible.
The unlock path was given a supersede escape hatch in _lock_unlocked; the lock path has no equivalent. Either add one to _lock_locked (e.g. a pending_state_change_lock marker set here and consumed there, mirroring the prior_slot == 0 mechanism), or drop this assignment and let _lock_locked own the transition.
|
Thanks @tykeal! Applied the cleanup in
|
|
Re-verified at Blocker 4 is resolved — Blockers 1, 2 and 3 are unaddressed. so the five added lines in
Details and before/after output are in that review; I won't repeat them here. Local tooling at this head is clean: 1059 passed, The four red checks are infrastructure, not defects. Also still open, minor: the |
|
Thanks for the thorough review and edge-case analysis! @tykeal I've updated the implementation in commit
|
tykeal
left a comment
There was a problem hiding this comment.
LGTM — approved. The three follow-ups below are all non-blocking; the approval is not conditional on any of them.
Verified at 180f29ac: all three blockers from #701 (review) reproduce cleanly against merge base 83a572e and now match base exactly, and a 26-sequence differential matrix of push-provider event orderings shows no regressions against base. Each blocker has a genuine regression test (mutation-verified: reverting the _lock_unlocked token guard, the _lock_locked token guard, or the lock_state != UNLOCKED guard each fails a distinct test). Supersede behaviour is unaffected — 7 tests still guard it. Locally: 1063 passed, ruff/format/mypy clean, 100% patch coverage.
Three non-blocking follow-ups, none of which need to hold this up:
- The token discards are untested — removing either
_pending_provider_unlock_event.discard(L1373) or_pending_provider_lock_event.discard(L1489) leaves the suite green but produces duplicate bus events and notifications. Three short tests (duplicate lock event, duplicate unlock event, oscillation) would cover it. _delete_lock(L1913-1916) clears_last_unlock_code_slot,_state_change_autolock_startedand the pending keypad notification, but not the two new sets. Entry IDs are ULIDs so there is no reuse hazard, but the sets grow unboundedly across deletions._lock_unlockeddiscards its token before the throttle check while_lock_lockedchecks the throttle first. With the first provider event throttled, the unlock side yieldsbus=0 notify=0where base yieldsbus=1; the lock side is correct. Narrow edge case; moving the L1373 discard below the throttle block makes the two paths consistent.
Note that no CI has run on this head — gh pr checks reports no checks and the commit status is pending with 0 contexts. Worth re-triggering before merge.
|
Addressed the non-blocking follow-up items in commit
|
|
Re-verified at Follow-ups 2 and 3 are resolved. The The discard move below the throttle gate is correct and does not open the failure mode it might appear to. A throttled provider event now returns with the token still armed, but that is the right semantics: the token means "side effects not yet emitted for this transition", and a throttled event emits none, so the next event correctly consumes it on the full-effect path. Both paths are now consistent and both match base. Probes A/B/C still match base ( Correcting a number I published in Follow-up 1 is half-covered. The mutual cross-discards in
No CI has run on this head. |
|
Added |
e9c6746 to
4694c99
Compare
…ange and last_unlock_code_slot
…e_provider_lock_event
…ss of state change order
…ding sets in _delete_lock
4694c99 to
5be9070
Compare
|
Re-verified at M6 is now caught. Deleting One minor note. Both duplicate-suppression tests kill their mutants through the internal set-membership assertion rather than the bus-event and notification counts. Removing just that assertion leaves each test passing under its mutation, because both events in each test are the same direction, so nothing resets the throttle between them and
|
…ertions load-bearing
|
Thanks @tykeal! Applied the suggested improvement in commit
|
|
Re-verified at The count assertions are now load-bearing: with the internal set assertions removed, M6 and M7 are still caught by the observable notification and bus-event counts. No open items from my side. |
Summary of Changes
"unlock"/"lock") in_handle_provider_lock_eventover lock entity state mismatch.kmlock.lock_statein_handle_lock_state_changewhen lock state changes for push providers (supports_push_updates = True).tests/test_coordinator.py.Ref #699